Skip to content

Script Loader: Defer single-page admin init until DOMContentLoaded (Trac #65103)#11611

Open
itzmekhokan wants to merge 2 commits intoWordPress:trunkfrom
itzmekhokan:fix/65103
Open

Script Loader: Defer single-page admin init until DOMContentLoaded (Trac #65103)#11611
itzmekhokan wants to merge 2 commits intoWordPress:trunkfrom
itzmekhokan:fix/65103

Conversation

@itzmekhokan
Copy link
Copy Markdown

Trac ticket

https://core.trac.wordpress.org/ticket/65103

Summary

Fixes a race condition that prevents the Connectors (/wp-admin/options-connectors.php) and Font Library admin screens from mounting in Chrome on fast-CDN-fronted hosts (reproducible on WordPress VIP). The failure surfaces as an empty mount div plus Uncaught Error: Cannot unlock an undefined object in the console.

Root cause

The four auto-generated files below register a -prerequisites classic-script handle with an empty src and attach an inline script that does a dynamic ESM import:

wp_register_script( '…-prerequisites', '', $asset['dependencies'], $asset['version'], true );
wp_add_inline_script( '…-prerequisites',
    'import("@wordpress/boot").then(mod => mod.init({…}));'
);

Because the handle has no src, only the inline is printed, and it runs as a classic <script> the instant the HTML parser reaches it.

@wordpress/boot is <link rel="modulepreload">-ed in <head>. On a fast CDN the bundle is effectively free, so the dynamic import resolves and the module evaluates before the parser has reached the classic deps it relies on (wp-private-apis, wp-components, wp-theme).

At its top-level @wordpress/boot reads window.wp.theme.privateApis, which is still undefined at that point, so unlock(undefined) throws and initSinglePage / init never runs.

Only Chrome + fast CDN reliably loses the race; Firefox/Safari schedule module eval slightly later, and local dev is slow enough that the classic deps usually finish first.

The fix (Option 1 from the ticket)

Wrap the dynamic import in DOMContentLoaded. The HTML spec guarantees DOMContentLoaded fires only after every parser-blocking classic <script> has executed, so wp.theme.privateApis (and the rest) are populated before @wordpress/boot evaluates. A document.readyState === "loading" guard preserves behavior when the inline is ever re-run after DOM ready (e.g. AJAX-injected contexts).

Before:

import("@wordpress/boot").then(mod => mod.initSinglePage({mountId: "…", routes: [...]}));

After:

(function(){
  var run = function(){
    import("@wordpress/boot").then(function(mod){
      mod.initSinglePage({mountId: "…", routes: [...]});
    });
  };
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", run);
  } else {
    run();
  }
})();

Files changed

All four auto-generated files that share this pattern:

  • src/wp-includes/build/pages/options-connectors/page.php (init)
  • src/wp-includes/build/pages/options-connectors/page-wp-admin.php (initSinglePage)
  • src/wp-includes/build/pages/font-library/page.php (init)
  • src/wp-includes/build/pages/font-library/page-wp-admin.php (initSinglePage)

Each file's wp_add_inline_script(…) call is updated, plus an explanatory comment referencing Trac #65103. No registration logic, dependency lists, script module graph, or style registration is touched.

Why not the other options from the ticket

  • Option 2 (emit the inline as a module script) would require a new wp_add_inline_script_module() API or a script_loader_tag filter — out of scope for a bugfix.
  • Option 3 (move initSinglePage into loader.js) would change the public module contract, require a coordinated Gutenberg-side change, and migrate every downstream consumer of the pattern.

Follow-up note for committers

These files are marked "Auto-generated by build process. Do not edit this file manually." The generator lives in Gutenberg's build pipeline. A search of the local Gutenberg checkout finds no matching template string, so the wordpress-develop copies are effectively the current source of truth — but the Gutenberg-side template should receive the same change (or the next sync from Gutenberg will silently revert this). Happy to open a follow-up Gutenberg PR once this lands.

Test plan

  • Manual: on a CloudFront-fronted dev environment in Chrome (or with DevTools Network throttling set to simulate CDN on module responses only), load /wp-admin/options-connectors.php and the Font Library admin screen. Confirm the app mounts and "Cannot unlock an undefined object" is not logged.
  • Manual: hard-reload several times to exercise the race window.
  • Manual: repeat in Firefox and Safari to confirm no regression.
  • Confirm document.readyState guard: visit the page, then in the console re-evaluate the inline body and verify mod.init / mod.initSinglePage is still called exactly once (behavior preserved for late-arriving scripts).

Related

  • Not a regression — the pattern worked everywhere the race was too tight to fire. Surfaces only in fast-CDN + Chrome setups.
  • Connectors screen landed in 7.0; Font Library uses the same generator output.
  • Any downstream consumer using the same wp_register_script('-prerequisites', '', …) + inline import("@wordpress/boot") pattern has the same bug.

@github-actions
Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props khokansardar.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

The Connectors and Font Library admin screens register a `-prerequisites`
classic-script handle with an empty `src` and attach an inline script that
does `import("@wordpress/boot").then(mod => mod.init(...))`. Because the
handle has no `src`, only the inline is printed, and it runs as a classic
script the moment the parser reaches it.

On fast CDN-fronted hosts (WordPress VIP, CloudFront, etc.) in Chrome,
`@wordpress/boot` is already `<link rel="modulepreload">`-ed, so the
dynamic import resolves and the module evaluates before the parser has
reached the classic deps it relies on (`wp-private-apis`, `wp-components`,
`wp-theme`). At its top-level `@wordpress/boot` reads
`window.wp.theme.privateApis`, which is still undefined at that point,
causing `unlock(undefined)` to throw "Cannot unlock an undefined object".
The mount element stays empty and `initSinglePage` / `init` never runs.

Wrap the dynamic import in `DOMContentLoaded`. The HTML spec guarantees
`DOMContentLoaded` fires only after every parser-blocking classic
`<script>` has executed, so all required globals are populated before
`@wordpress/boot` evaluates. A `document.readyState` guard preserves the
existing behavior when the inline is evaluated after DOM ready (e.g.
injected late).

Applied to all four auto-generated files that share the pattern:
 * src/wp-includes/build/pages/options-connectors/page.php
 * src/wp-includes/build/pages/options-connectors/page-wp-admin.php
 * src/wp-includes/build/pages/font-library/page.php
 * src/wp-includes/build/pages/font-library/page-wp-admin.php

Fixes #65103.

Made-with: Cursor
Follow-up to [65103] feedback. Adjust the multi-line `/* */` comments
added for the DOMContentLoaded deferral so they match the WordPress PHP
inline-documentation conventions: no DocBlock-style summary-then-blank
separator (that structure is reserved for `/** */`), and use the short
`See #65103.` form for Trac references instead of `See Trac #65103.` to
match usages throughout core (`See #38883`, `See #40146`, etc.).

See #65103.

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant